/*
 * Copyright (c) 2020 Contributors to the Eclipse Foundation
 *
 * See the NOTICE file(s) distributed with this work for additional
 * information regarding copyright ownership.
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License 2.0 which is available at
 * http://www.eclipse.org/legal/epl-2.0, or the Eclipse Distribution License 1.0
 * which is available at http://www.eclipse.org/org/documents/edl-v10.php.
 *
 * SPDX-License-Identifier: EPL-2.0 OR BSD-3-Clause
 */

package org.eclipse.lyo.server.oauth.webapp.services;

import java.io.IOException;
import java.net.InetAddress;
import java.net.URISyntaxException;
import java.util.List;
import java.util.UUID;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import org.apache.wink.json4j.JSON;
import org.apache.wink.json4j.JSONException;
import org.apache.wink.json4j.JSONObject;
import org.eclipse.lyo.server.oauth.core.Application;
import org.eclipse.lyo.server.oauth.core.AuthenticationException;
import org.eclipse.lyo.server.oauth.core.OAuthConfiguration;
import org.eclipse.lyo.server.oauth.core.OAuthRequest;
import org.eclipse.lyo.server.oauth.core.consumer.ConsumerStoreException;
import org.eclipse.lyo.server.oauth.core.consumer.LyoOAuthConsumer;
import org.eclipse.lyo.server.oauth.core.token.TokenStrategy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import jakarta.ws.rs.FormParam;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import jakarta.ws.rs.core.UriBuilder;
import net.oauth.OAuth;
import net.oauth.OAuth.Parameter;
import net.oauth.OAuthAccessor;
import net.oauth.OAuthException;
import net.oauth.OAuthMessage;
import net.oauth.OAuthProblemException;
import net.oauth.OAuthValidator;
import net.oauth.server.OAuthServlet;

/**
 * Issues OAuth request tokens, handles authentication, and then exchanges
 * request tokens for access tokens based on the OAuth configuration set in the
 * {@link OAuthConfiguration} singleton.
 *
 * @author Samuel Padgett
 * @see <a href="http://tools.ietf.org/html/rfc5849">The OAuth 1.0 Protocol</a>
 */
@Path("/oauth")
public class OAuthService {
    @Context
    protected HttpServletRequest httpRequest;

    @Context
    protected HttpServletResponse httpResponse;

    private static final Logger log = LoggerFactory.getLogger(OAuthService.class);

    @GET
    @Path("/requestToken")
    public Response doGetRequestToken() throws IOException, ServletException {
        return doPostRequestToken();
    }

    /**
     * Responds with a request token and token secret.
     *
     * @return the response
     * @throws IOException      on I/O errors
     * @throws ServletException on servlet errors
     */
    @POST
    @Path("/requestToken")
    public Response doPostRequestToken() throws IOException, ServletException {
        try {
            OAuthRequest oAuthRequest = validateRequest();

            // Generate the token.
            OAuthConfiguration.getInstance().getTokenStrategy().generateRequestToken(oAuthRequest);

            // Check for OAuth 1.0a authentication.
            boolean callbackConfirmed = confirmCallback(oAuthRequest);

            // Respond to the consumer.
            OAuthAccessor accessor = oAuthRequest.getAccessor();
            return respondWithToken(accessor.requestToken, accessor.tokenSecret, callbackConfirmed);
        } catch (OAuthException e) {
            return respondWithOAuthProblem(e);
        }
    }

    /**
     * Responds with a web page to log in.
     *
     * @return the response
     * @throws IOException      on I/O errors
     * @throws ServletException on internal errors validating the request
     */
    @GET
    @Path("/authorize")
    public Response authorize() throws ServletException, IOException {
        try {
            /*
             * Check that the request token is valid and determine what consumer it's for.
             * The OAuth spec does not require that consumers pass the consumer key to the
             * authorization page, so we must track this in the TokenStrategy
             * implementation.
             */
            OAuthMessage message = OAuthServlet.getMessage(httpRequest, null);
            OAuthConfiguration config = OAuthConfiguration.getInstance();
            String consumerKey = config.getTokenStrategy().validateRequestToken(httpRequest, message);

            LyoOAuthConsumer consumer = OAuthConfiguration.getInstance().getConsumerStore().getConsumer(consumerKey);

            // Pass some data to the JSP.
            httpRequest.setAttribute("requestToken", message.getToken());
            httpRequest.setAttribute("consumerName", consumer.getName());
            httpRequest.setAttribute("callback", getCallbackURL(message, consumer));
            boolean callbackConfirmed = consumer.getOAuthVersion() == LyoOAuthConsumer.OAuthVersion.OAUTH_1_0A;
            httpRequest.setAttribute("callbackConfirmed", callbackConfirmed);

            // The application name is displayed on the OAuth login page.
            httpRequest.setAttribute("applicationName", config.getApplication().getName());

            httpResponse.setHeader(OAuthServerConstants.HDR_CACHE_CONTROL, OAuthServerConstants.NO_CACHE);
            if (config.getApplication().isAuthenticated(httpRequest)) {
                // Show the grant access page.
                httpRequest.getRequestDispatcher("/oauth/authorize.jsp").forward(httpRequest, httpResponse);
            } else {
                // Show the login page.
                httpRequest.getRequestDispatcher("/oauth/login.jsp").forward(httpRequest, httpResponse);
            }

            return null;
        } catch (OAuthException e) {
            return respondWithOAuthProblem(e);
        }
    }

    /**
     * Validates the ID and password on the authorization form. This is intended to
     * be invoked by an XHR on the login page.
     *
     * @return the response, 409 if login failed or 204 if successful
     */
    @POST
    @Path("/login")
    public Response login(@FormParam("id") String id, @FormParam("password") String password,
            @FormParam("requestToken") String requestToken) {
        CSRFPrevent.check(httpRequest);

        try {
            OAuthConfiguration.getInstance().getApplication().login(httpRequest, id, password);
        } catch (OAuthException e) {
            return Response.status(Status.SERVICE_UNAVAILABLE).build();
        } catch (AuthenticationException e) {
            String message = e.getMessage();
            if (message == null || "".equals(message)) {
                message = "Incorrect username or password.";
            }
            return Response.status(Status.CONFLICT).entity(message).type(MediaType.TEXT_PLAIN).build();
        }

        try {
            OAuthConfiguration.getInstance().getTokenStrategy().markRequestTokenAuthorized(httpRequest, requestToken);
        } catch (OAuthException e) {
            return Response.status(Status.CONFLICT).entity("Request token invalid.").type(MediaType.TEXT_PLAIN).build();
        }

        return Response.noContent().build();
    }

    @POST
    @Path("/internal/approveToken")
    public Response authorize(@FormParam("requestToken") String requestToken) {
        CSRFPrevent.check(httpRequest);

        try {
            if (!OAuthConfiguration.getInstance().getApplication().isAuthenticated(httpRequest)) {
                return Response.status(Status.FORBIDDEN).build();
            }
        } catch (OAuthProblemException e) {
            return Response.status(Status.SERVICE_UNAVAILABLE).build();
        }

        return authorizeToken(requestToken);
    }

    private Response authorizeToken(String requestToken) {
        try {
            OAuthConfiguration.getInstance().getTokenStrategy().markRequestTokenAuthorized(httpRequest, requestToken);
        } catch (OAuthException e) {
            return Response.status(Status.CONFLICT).entity("Request token invalid.").type(MediaType.TEXT_PLAIN).build();
        }

        return Response.noContent().build();
    }

    @GET
    @Path("/accessToken")
    public Response doGetAccessToken() throws IOException, ServletException {
        return doPostAccessToken();
    }

    /**
     * Responds with an access token and token secret for valid OAuth requests. The
     * request must be signed and the request token valid.
     *
     * @return the response
     * @throws IOException      on I/O errors
     * @throws ServletException on servlet errors
     */
    @POST
    @Path("/accessToken")
    public Response doPostAccessToken() throws IOException, ServletException {
        try {
            // Validate the request is signed and check that the request token
            // is valid.
            OAuthRequest oAuthRequest = validateRequest();
            OAuthConfiguration config = OAuthConfiguration.getInstance();
            TokenStrategy strategy = config.getTokenStrategy();
            strategy.validateRequestToken(httpRequest, oAuthRequest.getMessage());

            // The verification code MUST be passed in the request if this is
            // OAuth 1.0a.
            if (!config.isV1_0Allowed()
                    || oAuthRequest.getConsumer().getOAuthVersion() == LyoOAuthConsumer.OAuthVersion.OAUTH_1_0A) {
                strategy.validateVerificationCode(oAuthRequest);
            }

            // Generate a new access token for this accessor.
            strategy.generateAccessToken(oAuthRequest);

            // Send the new token and secret back to the consumer.
            OAuthAccessor accessor = oAuthRequest.getAccessor();
            return respondWithToken(accessor.accessToken, accessor.tokenSecret);
        } catch (OAuthException e) {
            return respondWithOAuthProblem(e);
        }
    }

    /**
     * Generates a provisional consumer key. This request must be later approved by
     * an administrator.
     *
     * @return a JSON response with the provisional key
     * @throws IOException
     * @throws NullPointerException
     * @see <a href=
     *      "https://jazz.net/wiki/bin/view/Main/RootServicesSpecAddendum2">Jazz
     *      Root Services Spec Addendum2</a>
     */
    @POST
    @Path("/requestKey")
    // Some consumers do not set an appropriate Content-Type header.
    // @Consumes({ MediaType.APPLICATION_JSON })
    @Produces({ MediaType.APPLICATION_JSON })
    public Response provisionalKey() throws NullPointerException, IOException {
        try {
            // Create the consumer from the request.
            JSONObject request = (JSONObject) JSON.parse(httpRequest.getInputStream());

            String name = null;
            if (request.has("name") && request.get("name") != null) {
                name = request.getString("name");
            }

            if (name == null || name.trim().equals("")) {
                name = getRemoteHost();
            }

            String secret = request.getString("secret");

            boolean trusted = false;
            if (request.has("trusted")) {
                trusted = "true".equals(request.getString("trusted"));
            }

            String key = UUID.randomUUID().toString();
            LyoOAuthConsumer consumer = new LyoOAuthConsumer(key, secret);
            consumer.setName(name);
            consumer.setProvisional(true);
            consumer.setTrusted(trusted);

            // Add the consumer to the store.
            OAuthConfiguration.getInstance().getConsumerStore().addConsumer(consumer);

            // Respond with the consumer key.
            JSONObject response = new JSONObject();
            response.put("key", key);

            return Response.ok(response.write())
                    .header(OAuthServerConstants.HDR_CACHE_CONTROL, OAuthServerConstants.NO_CACHE).build();
        } catch (JSONException e) {
            log.info("Encountered an exception while processing JSON: {}", e.getMessage());
            return Response.status(Status.BAD_REQUEST).build();
        } catch (ConsumerStoreException e) {
            log.warn("Exception caught from consumer store: {}", e.getMessage());
            return Response.status(Status.SERVICE_UNAVAILABLE).type(MediaType.TEXT_PLAIN).entity(e.getMessage())
                    .build();
        }
    }

    /**
     * Shows the approval page for a single provisional consumer. Shows the consumer
     * management page instead if no key is passed in.
     *
     * @param key the consumer
     * @return the approve consumer page
     * @throws ServletException on errors showing the JSP
     * @throws IOException      on errors showing the JSP
     * @see #showConsumerKeyManagementPage()
     */
    @GET
    @Path("/approveKey")
    @Produces({ MediaType.TEXT_HTML })
    public Response showApproveKeyPage(@QueryParam("key") String key) throws ServletException, IOException {
        if (key == null || "".equals(key)) {
            return showConsumerKeyManagementPage();
        }

        try {
            Application app = OAuthConfiguration.getInstance().getApplication();

            // The application name is displayed on approval page.
            httpRequest.setAttribute("applicationName", app.getName());

            if (!app.isAdminSession(httpRequest)) {
                return showAdminLogin();
            }

            LyoOAuthConsumer provisionalConsumer = OAuthConfiguration.getInstance().getConsumerStore().getConsumer(key);

            if (provisionalConsumer == null) {
                return Response.status(Status.BAD_REQUEST).build();
            }

            httpResponse.setHeader(OAuthServerConstants.HDR_CACHE_CONTROL, OAuthServerConstants.NO_CACHE);
            httpRequest.setAttribute("consumerName", provisionalConsumer.getName());
            httpRequest.setAttribute("consumerKey", provisionalConsumer.consumerKey);
            httpRequest.setAttribute("trusted", provisionalConsumer.isTrusted());
            final String dispatchTo = (provisionalConsumer.isProvisional()) ? "/oauth/approveKey.jsp"
                    : "/oauth/keyAlreadyApproved.jsp";
            httpRequest.getRequestDispatcher(dispatchTo).forward(httpRequest, httpResponse);
            return null;

        } catch (ConsumerStoreException e) {
            log.warn("Exception caught from consumer store: {}", e.getMessage());
            return Response.status(Status.CONFLICT).type(MediaType.TEXT_PLAIN).entity(e.getMessage()).build();
        } catch (OAuthProblemException e) {
            return respondWithOAuthProblem(e);
        }
    }

    /**
     * Shows the consumer management page, which allows administrator to approve or
     * remove OAuth consumers.
     *
     * @return the consumer management page
     * @throws ServletException on JSP errors
     * @throws IOException      on JSP errors
     */
    @GET
    @Path("/admin")
    public Response showConsumerKeyManagementPage() throws ServletException, IOException {
        try {
            Application app = OAuthConfiguration.getInstance().getApplication();

            httpRequest.setAttribute("applicationName", app.getName());
            if (!app.isAdminSession(httpRequest)) {
                return showAdminLogin();
            }
        } catch (OAuthException e) {
            return Response.status(Status.SERVICE_UNAVAILABLE).build();
        }

        httpResponse.setHeader(OAuthServerConstants.HDR_CACHE_CONTROL, OAuthServerConstants.NO_CACHE);
        httpRequest.getRequestDispatcher("/oauth/manage.jsp").forward(httpRequest, httpResponse);
        return null;
    }

    /**
     * Validates that the ID and password are for an administrator. This is used by
     * the admin login page to protect the OAuth administration pages.
     *
     * @return the response, 409 if login failed or 204 if successful
     */
    @POST
    @Path("/adminLogin")
    public Response login(@FormParam("id") String id, @FormParam("password") String password) {
        CSRFPrevent.check(httpRequest);

        try {
            Application app = OAuthConfiguration.getInstance().getApplication();
            app.login(httpRequest, id, password);

            if (app.isAdminSession(httpRequest)) {
                return Response.noContent().build();
            }

            return Response.status(Status.CONFLICT).entity("The user '" + id + "' is not an administrator.")
                    .type(MediaType.TEXT_PLAIN).build();
        } catch (OAuthException e) {
            return Response.status(Status.SERVICE_UNAVAILABLE).build();
        } catch (AuthenticationException e) {
            String message = e.getMessage();
            if (message == null || "".equals(message)) {
                message = "Incorrect username or password.";
            }
            return Response.status(Status.CONFLICT).entity(message).type(MediaType.TEXT_PLAIN).build();
        }
    }

    protected boolean confirmCallback(OAuthRequest oAuthRequest) throws OAuthException {
        boolean callbackConfirmed = OAuthConfiguration.getInstance().getTokenStrategy().getCallback(httpRequest,
                oAuthRequest.getAccessor().requestToken) != null;
        if (callbackConfirmed) {
            oAuthRequest.getConsumer().setOAuthVersion(LyoOAuthConsumer.OAuthVersion.OAUTH_1_0A);
        } else {
            if (!OAuthConfiguration.getInstance().isV1_0Allowed()) {
                throw new OAuthProblemException(OAuth.Problems.OAUTH_PARAMETERS_ABSENT);
            }

            oAuthRequest.getConsumer().setOAuthVersion(LyoOAuthConsumer.OAuthVersion.OAUTH_1_0);
        }

        return callbackConfirmed;
    }

    /**
     * Validates this is a known consumer and the request is valid using
     * {@link OAuthValidator#validateMessage(net.oauth.OAuthMessage, OAuthAccessor)}.
     * Does <b>not</b> check for any tokens.
     *
     * @return an OAuthRequest
     * @throws OAuthException if the request fails validation
     * @throws IOException    on I/O errors
     */
    protected OAuthRequest validateRequest() throws OAuthException, IOException {
        OAuthRequest oAuthRequest = new OAuthRequest(httpRequest);
        try {
            OAuthValidator validator = OAuthConfiguration.getInstance().getValidator();
            validator.validateMessage(oAuthRequest.getMessage(), oAuthRequest.getAccessor());
        } catch (URISyntaxException e) {
            throw new WebApplicationException(e, Status.INTERNAL_SERVER_ERROR);
        } catch (OAuthProblemException e) {
            log.error("OAuthProblemException caught when validating the request. {}", e.toString());
            throw e;
        }
        return oAuthRequest;
    }

    protected Response respondWithToken(String token, String tokenSecret) throws IOException {
        return respondWithToken(token, tokenSecret, false);
    }

    protected Response respondWithToken(String token, String tokenSecret, boolean callbackConfirmed)
            throws IOException {
        List<Parameter> oAuthParameters = OAuth.newList(OAuth.OAUTH_TOKEN, token, OAuth.OAUTH_TOKEN_SECRET,
                tokenSecret);
        if (callbackConfirmed) {
            oAuthParameters.add(new Parameter(OAuth.OAUTH_CALLBACK_CONFIRMED, "true"));
        }

        String responseBody = OAuth.formEncode(oAuthParameters);
        return Response.ok(responseBody).type(MediaType.APPLICATION_FORM_URLENCODED)
                .header(OAuthServerConstants.HDR_CACHE_CONTROL, OAuthServerConstants.NO_CACHE).build();
    }

    protected Response respondWithOAuthProblem(OAuthException e) throws IOException, ServletException {
        try {
            OAuthServlet.handleException(httpResponse, e,
                    OAuthConfiguration.getInstance().getApplication().getRealm(httpRequest));
        } catch (OAuthProblemException serviceUnavailableException) {
            return Response.status(Status.SERVICE_UNAVAILABLE).build();
        }

        return Response.status(Status.UNAUTHORIZED).build();
    }

    private String getCallbackURL(OAuthMessage message, LyoOAuthConsumer consumer) throws IOException, OAuthException {
        String callback = null;
        switch (consumer.getOAuthVersion()) {
        case OAUTH_1_0:
            if (!OAuthConfiguration.getInstance().isV1_0Allowed()) {
                throw new OAuthProblemException(OAuth.Problems.VERSION_REJECTED);
            }

            // If this is OAuth 1.0, the callback should be a request parameter.
            callback = message.getParameter(OAuth.OAUTH_CALLBACK);
            break;

        case OAUTH_1_0A:
            // If this is OAuth 1.0a, the callback was passed when the consumer
            // asked for a request token.
            String requestToken = message.getToken();
            callback = OAuthConfiguration.getInstance().getTokenStrategy().getCallback(httpRequest, requestToken);
        }

        if (callback == null) {
            return null;
        }

        UriBuilder uriBuilder = UriBuilder.fromUri(callback).queryParam(OAuth.OAUTH_TOKEN, message.getToken());
        if (consumer.getOAuthVersion() == LyoOAuthConsumer.OAuthVersion.OAUTH_1_0A) {
            String verificationCode = OAuthConfiguration.getInstance().getTokenStrategy()
                    .generateVerificationCode(httpRequest, message.getToken());
            uriBuilder.queryParam(OAuth.OAUTH_VERIFIER, verificationCode);
        }

        return uriBuilder.build().toString();
    }

    private String getRemoteHost() {
        try {
            // Try to get the hostname of the consumer.
            return InetAddress.getByName(httpRequest.getRemoteHost()).getCanonicalHostName();
        } catch (Exception e) {
            /*
             * Not fatal, and we shouldn't fail here. Fall back to returning
             * ServletRequest.getRemoveHost(). It might be the IP address, but that's better
             * than nothing.
             */
            return httpRequest.getRemoteHost();
        }
    }

    private Response showAdminLogin() throws ServletException, IOException {
        httpResponse.setHeader(OAuthServerConstants.HDR_CACHE_CONTROL, OAuthServerConstants.NO_CACHE);
        StringBuffer callback = httpRequest.getRequestURL();
        String query = httpRequest.getQueryString();
        if (query != null) {
            callback.append('?');
            callback.append(query);
        }
        httpRequest.setAttribute("callback", callback.toString());
        httpRequest.getRequestDispatcher("/oauth/adminLogin.jsp").forward(httpRequest, httpResponse);
        return null;
    }
}
